#!/usr/bin/env python2.6
# Bot Bot
# by Cee Pee
# TODO: Support Python 3.1 as well (requires changes to ircbot and irclib...)

from deps.Settings import Settings
from ircbot import SingleServerIRCBot
from irclib import nm_to_n, nm_to_uh, nm_to_h, nm_to_u, irc_lower, is_channel
from subprocess import Popen, PIPE
import irclib
import os
import random
import re
import time

__version__ = "0"

class BotError(Exception):
	"""Error that occured while processing a command."""

class BotBot(SingleServerIRCBot):
	"""Main IRC bot class."""
	
	commands = {
		"help": {
			"options": [{"name": "command", "default": None}],
			"help": "Displays command-specific help.",
		},
		"commands": {
			"options": [{"name": "filter", "default": None}],
			"help": "Displays all commands, or a subset thereof.",
		},
		"disconnect": {"access": "owner", "help": "Disconnect the bot."},
		"die": {"access": "owner", "help": "Kills the bot's process."},
		"ping": {"help": "Return a pong."},
		"eval": {
			"options": ["expression"],
			"access": "owner",
			"help": "Evaluate Python code",
		},
		"do": {
			"options": ["func"],
			"access": "owner",
			"help": "Sends an action to the server"
		},
		"nick": {
			"options": ["name"],
			"access": "owner",
			"help": "Changes the bot's nickname.",
		},
		"join": {
			"options": ["channel", {"name": "key", "default": ""}],
			"help": "Joins a channel.",
		},
		"part": {
			"options": [
				{"name": "channel", "default": None},
				{"name": "message", "default": ""}
			],
			"access": "owner",
			"help": "Leaves a channel.",
		},
		"invite": {
			"options": ["nick", {"name": "channel", "default": None}],
			"access": "trusted",
			"help": "Invites somone to a channel.",
		},
		"msg": {
			"options": ["target"],
			"access": "trusted",
			"help": "Sends a message to a user or channel.",
		},
		"users": {
			"options": [{"name": "channel", "default": None}],
			"help": "Shows the number of users in a channel.",
		},
		"rand": {
			"options": [
				{"name": "type", "default": "float", "choices": ["int", "float", "list"]},
			],
			"help": "Shows a random integer number or floating point decimal.",
		},
		"random": {"alias": "rand"},
		"coin": {
			"options": [{"name": "number", "type": "int", "default": 1}],
			"help": "Flips a number of coins."
		},
		"flip": {"alias": "coin"},
		"match": {
			"options": ["pattern", "string", {"name": "flags", "default": ""}],
			"help": "Scans through a string to find a location where a regular expression pattern makes a match.",
			"access": "trusted",
		},
		"replace": {
			"options": [
				"pattern", "replace", "string",
				{"name": "count", "type": "int", "default": 1},
				{"name": "flags", "default": ""},
			],
			"help": "Makes replacements according to regular exprssions in a string.",
			"access": "trusted",
		},
		"stats": {"help": "Displays channel statistics."},
		"log": {
			"options": [{"name": "date", "default": None}],
			"help": "Sends a channel log for a certain date via DCC SEND to the user.",
			"access": "disabled",
		},
		"wall": {
			"options": ["message"],
			"help": "Sends a message to all logged-in users of the machine this bot is running on. Unix hosts only.",
		},
		"ls": {
			"options": ["directory"],
			"help": "Shows all files in a directory on the server.",
			"access": "trusted",
		},
		"tail": {
			"options": [
				{"name": "lines", "type": "int", "default": 3},
				{"name": "file", "default": None},
			],
			"help": "Displays the last few lines of an IRC log.",
			"access": "disabled",
		}
	}
	
	log_formats = {
		"pubmsg": "<{nick}> {text}",
		"pubnotice": "[{nick}] {text}",
		"action": "* {nick} {text}",
		"mode": "# {nick} sets mode {text}",
		"topic": "# {nick} sets topic to '{text}'",
		"join": "> {nick} has joined",
		"part": "< {nick} has left ({text})",
		"quit": "< {nick} has quit ({text})",
		"kick": "< {nick} was kicked by {op} ({message})",
	}
	log_name_format = "%Y-%m-%d"
	log_time_format = "%H:%M:%S"
	
	ctcp = ["CLIENTINFO", "VERSION", "PING", "OS", "ACTION", "DCC"]
	
	privileges = {
		"owner": ["^.*!~CeePee@86\.92\.94\.171$"],
		"trusted": [
			"^.*!.*@86\.92\.94\.171$",
			"^.*!~(4c0a845f|2674c05f)@gateway/web/freenode/.*$", # JB/JB_school
		],
	}
	
	def __init__(self, options):
		"""Sets up required parameters."""
		
		self.channel = options.channel
		self.identification = options.identification
		self.password = options.password
		self.log = options.log
		
		self.prefix = "!"
		
		if options.debug:
			self.debug = True
			irclib.DEBUG = 1
		else:
			self.debug = False
		
		server_info = [options.server, options.port]
		if self.identification == "pass":
			server_info.append(self.password)
		
		SingleServerIRCBot.__init__(self, [tuple(server_info)], options.nickname, options.realname)
	
	def get_version(self):
		return("irclib/{0} BotBot/{1}".format(".".join([str(v) for v in irclib.VERSION]), __version__))
	
	def on_nicknameinuse(self, c, e):
		"""Changes the nickname if it's taken on connect."""
		
		c.nick(c.get_nickname() + "_")
	
	def on_welcome(self, c, e):
		"""Identifies to NickServ, and joins the primary channel."""
		
		if self.password and self.identification == "nickserv":
			c.privmsg("NickServ", "IDENTIFY " + self.password)
		
		c.join(self.channel)
	
	def on_ctcp(self, c, e):
		"""Replies to CTCP request events."""
		
		code = e.arguments()[0]
		reply = ""
		
		# TODO: Put into functions?
		if code == "CLIENTINFO":
			reply = " ".join(self.ctcp)
		elif code == "VERSION":
			reply = self.get_version()
		elif code == "PING":
			if len(e.arguments()) > 1:
				reply = e.arguments()[1]
		elif code == "TIME":
			reply = time.strftime("%a, %d %b %Y %H:%M:%S %z", time.localtime())
		elif code == "OS":
			# TODO: Quite ugly output
			reply = __import__("platform").platform()
		elif code == "DCC":
			if len(e.arguments()) > 1 and e.arguments()[1].split(" ", 1)[0] == "CHAT":
				return(self.on_dccchat(c, e))
		
		# TODO: Send ERRMSG?
		if reply != "":
			c.ctcp_reply(nm_to_n(e.source()), code + " " + reply)
	
	def on_mode(self, c, e):
		self.log_text(c, e)
	
	def on_topic(self, c, e):
		self.log_text(c, e)
	
	def on_privmsg(self, c, e):
		"""Attempts to run a command on a private message."""
		
		self.prefix = ""
		self.do_command(e, e.arguments()[0].strip())
	
	def on_pubmsg(self, c, e):
		"""Attempts to run a command on a public message."""
		
		self.prefix = "!"
		
		args = e.arguments()[0]
		a = args.split(":", 1)
		if len(a) > 1 and irc_lower(a[0]) == irc_lower(c.get_nickname()):
			self.do_command(e, a[1].strip())
		elif args.startswith("!"):
			self.do_command(e, args[1:].strip())
		
		self.log_text(c, e)
	
	def on_action(self, c, e):
		self.log_text(c, e)
	
	def on_pubnotice(self, c, e):
		self.log_text(c, e)
	
	def on_privnotice(self, c, e):
		"""Displays notices from e.g. NickServ in the shell."""
		
		print("{0}: {1}".format(nm_to_n(e.source()), " ".join(e.arguments())))
	
	def on_join(self, c, e):
		self.log_count(c, e.source(), e.target(), 0)
		self.log_text(c, e)
	
	def on_part(self, c, e):
		self.log_count(c, e.source(), e.target(), -2)
		self.log_text(c, e)
	
	def on_kick(self, c, e):
		self.log_count(c, e.arguments()[0], e.target(), -2)
		self.log_text(c, e, nick=e.arguments()[0], text=e.arguments()[1], op=nm_to_n(e.source()))
	
	def on_quit(self, c, e):
		self.log_count(c, e.source(), self.channel, -2)
		if self.channel in self.channels and self.channels[self.channel].has_user(nm_to_n(e.source())):
			self.log_text(c, e, channel=self.channel)
	
	def log_text(self, c, e, channel=None, nick=None, text=None, **kw):
		if channel is None:
			channel = e.target()
		
		if self.log and is_channel(channel) and channel == self.channel and e.eventtype() in self.log_formats:
			format = "[{time}] " + self.log_formats[e.eventtype()] + "\n"
			
			if nick is None:
				nick = nm_to_n(e.source())
			if text is None and e.arguments():
				text = e.arguments()[0]
			
			f = open("logs/{0}.log".format(time.strftime(self.log_name_format)), mode="a", encoding=c.charset)
			f.write(format.format(time=time.strftime(self.log_time_format), nick=nick, text=text, **kw))
			f.close()
	
	def log_count(self, c, user, target, correction):
		"""Logs the number of users in the primary user into a file.
		Correction is used to fix the number of users currently stored in the
		`Channel` object, which is usually incorrect, because it's not updated
		before we reach this. Plus, we don't count the bot."""
		
		nick = nm_to_n(user)
		
		if irc_lower(nick) != irc_lower(c.get_nickname()) and target in self.channels and self.channels[target].has_user(nick):
			f = open("ircusers", mode="w")
			f.write(str(len(self.channels[target].users()) + correction))
			f.close()
	
	def do_command(self, e, text):
		"""Runs a command based on received text."""
		
		words = text.split(" ")
		cmd = words[0]
		args = words[1:]
		
		user = e.source()
		nick = nm_to_n(user)
		c = self.connection
		
		t = e.target()
		if is_channel(t):
			target = t
		else:
			target = nick
		
		try:
			cmd_data = self.get_cmd_data(cmd)
			if cmd_data == False:
				raise(BotError("Unknown command: " + self.prefix + cmd))
			
			func = "cmd_" + cmd
			options = {}
			if isinstance(cmd_data, dict):
				if "options" in cmd_data:
					options = cmd_data["options"]
				
				if "access" in cmd_data:
					if not self.privileged(user, cmd_data["access"]):
						raise(BotError("I'm sorry {0}, I'm afraid I can't do that ({1} access required)".format(nick, cmd_data["access"])))
				
				if "func" in cmd_data:
					func = cmd_data["func"]
			
			opts = {}
			
			# TODO Make this a list
			error = ""
			missing = []
			
			for opt in options:
				if isinstance(opt, str):
					try:
						opts[opt] = args.pop(0)
					except IndexError:
						missing.append(opt)
				elif isinstance(opt, dict):
					if "name" in opt:
						name = opt["name"]
					else:
						continue
					
					try:
						x = args.pop(0)
						if "choices" in opt and x not in opt["choices"]:
							error = "Value for " + name + " not in choices"
						else:
							if "type" in opt:
								try:
									if opt["type"] == "int":
										x = int(x)
									elif opt["type"] == "list":
										x = x.split(",")
								except:
									error = "Incorrect value type for " + name
							
							opts[name] = x
					except IndexError:
						if "default" in opt:
							opts[name] = opt["default"]
						else:
							missing.append(name)
			
			if error != "" or missing:
				text = ""
				
				if error != "":
					text += error + ". "
				
				if missing:
					text += "Missing " + ", ".join(missing) + ". "
				
				text += self.get_usage(cmd, options)
				
				raise(BotError(text))
			
			if not hasattr(self, func):
				raise(BotError("Function for " + cmd + " does not exist."))
			
			getattr(self, func)(cmd, c, target, user, opts, args)
		except BotError as e:
			c.notice(target, "Error: " + str(e))
			#print("Error {0} ({1}): {2}".format(target, user, str(e))
	
	def get_cmd_data(self, cmd):
		"""Given a command name, returns the correct command data dictionary."""
		
		while cmd in self.__class__.commands:
			cmd_data = self.__class__.commands[cmd]
			if isinstance(cmd_data, dict) and "alias" in cmd_data:
				cmd = cmd_data["alias"]
			else:
				return(cmd_data)
		
		return(False)
	
	def get_usage(self, cmd, options):
		"""Returns a string that displays usage infomation for a command."""
		
		text = "Usage: " + self.prefix + cmd
		
		required = []
		optional = []
		for opt in options:
			if isinstance(opt, str):
				required.append(opt)
			elif isinstance(opt, dict):
				if "name" not in opt:
					continue
				
				if "default" in opt:
					optional.append(opt["name"])
				else:
					required.append(opt["name"])
		
		if required:
			text += " <" + "> <".join(required) + ">"
		if optional:
			text += " [" + "] [".join(optional) + "]"
		
		return(text)
	
	def privileged(self, source, access):
		"""Checks whether the source user is allowed to perform something that
		has an access level."""
		
		if access in self.privileges:
			for pattern in self.privileges[access]:
				if re.match(pattern, source):
					return(True)
		
		return(False)
	
	def get_channel(self, opt, target):
		"""Given an option value, returns it if it's a channel. If it is None,
		the current channel is returned."""
		
		if opt is not None:
			ch = opt
		else:
			ch = target
		
		if is_channel(ch) and ch in self.channels:
			return(ch)
		else:
			raise(BotError("Channel option was not given, or we're not in one."))
	
	def cmd_help(self, cmd, c, target, user, opts, args):
		if opts["command"]:
			# Show usage and "help"
			cmd_data = self.get_cmd_data(opts["command"])
			c.privmsg(target, "--- Command-specific help for " + self.prefix + opts["command"] + " ---")
			if isinstance(cmd_data, dict):
				if "alias" in cmd_data:
					c.privmsg(target, "Alias for " + cmd_data["alias"])
				
				if "options" in cmd_data:
					c.privmsg(target, self.get_usage(opts["command"], cmd_data["options"]))
				
				if "help" in cmd_data:
					c.privmsg(target, cmd_data["help"])
				
				if "access" in cmd_data:
					c.privmsg(target, "Required privilege: {0} access".format(cmd_data["access"]))
		else:
			c.notice(target, "Use !commands [filter] to show all commands or a subset of them, or !help <command> to display specific help.")
	
	def cmd_commands(self, cmd, c, target, user, opts, args):
		commands = []
		for (cmd, cmd_data) in self.__class__.commands.items():
			if (not opts["filter"] or cmd.startswith(opts["filter"])) and not(isinstance(cmd_data, dict) and "alias" in cmd_data):
				commands.append(cmd)
		
		commands.sort()		
		c.privmsg(target, "Commands: " + " ".join(commands))
	
	def cmd_disconnect(self, cmd, c, target, user, opts, args):
		self.disconnect(msg=" ".join(args))
	
	def cmd_die(self, cmd, c, target, user, opts, args):
		self.die(msg=" ".join(args))
	
	def cmd_ping(self, cmd, c, target, user, opts, args):
		c.privmsg(target, "Pong")
	
	def cmd_eval(self, cmd, c, target, user, opts, args):
		try:
			c.privmsg(target, eval(opts["expression"]))
		except:
			raise(BotError("Evaluation error."))
	
	def cmd_do(self, cmd, c, target, user, opts, args):
		if hasattr(c, opts["func"]):
			getattr(c, opts["func"])(*args)
		else:
			raise(BotError("No such action."))
	
	def cmd_nick(self, cmd, c, target, user, opts, args):
		c.nick(opts["name"])
	
	def cmd_join(self, cmd, c, target, user, opts, args):
		c.join(opts["channel"], opts["key"])
	
	def cmd_part(self, cmd, c, target, user, opts, args):
		ch = self.get_channel(opts["channel"], target)
		c.part(ch, opts["message"])
	
	def cmd_invite(self, cmd, c, target, user, opts, args):
		ch = self.get_channel(opts["channel"], target)
		c.invite(opts["nick"], ch)
	
	def cmd_msg(self, cmd, c, target, user, opts, args):
		c.privmsg(opts["target"], " ".join(args))
	
	def cmd_users(self, cmd, c, target, user, opts, args):
		ch = self.get_channel(opts["channel"], target)
		c.privmsg(target, "Other users in {0}: {1}".format(ch, len(self.channels[ch].users()) - 1))
		if ch == self.channel:
			self.log_count(c, user, target, -1)
	
	def cmd_rand(self, cmd, c, target, user, opts, args):
		if opts["type"] == "float":
			c.privmsg(target, str(random.random()))
		elif opts["type"] == "int":
			if len(args) > 1:
				min = args[0]
			else:
				min = 0
			if len(args) > 2:
				max = args[1]
			else:
				max = 10
			c.privmsg(target, str(random.randint(min, max)))
		elif opts["type"] == "list":
			if args:
				c.privmsg(target, random.choice(args))
			else:
				raise(BotError("Must specify a list of choices. Usage: " + self.prefix + cmd + " list <choice1> ... <choiceN>"))
	
	def cmd_coin(self, cmd, c, target, user, opts, args):
		if opts["number"] < 1 or opts["number"] > 10:
			raise(BotError("Number of coins out of range 1 <= n <= 10."))
		else:
			text = "Coin flip: "
			for x in range(1, opts["number"]):
				if random.randint(0, 1) == 1:
					text += "heads"
				else:
					text += "tails"
				
				if x < opts["number"]:
					text += ","
				else:
					text += "."
			
			c.privmsg(target, text)
	
	def get_re_flags(self, chars):
		flags = 0
		for char in chars:
			try:
				flags |= getattr(re, char.upper())
			except AttributeError:
				raise(BotError("Invalid flag " + char))
		
		return(flags)
	
	def cmd_match(self, cmd, c, target, user, opts, args):
		flags = self.get_re_flags(opts["flags"])
		
		m = re.match(opts["pattern"], opts["string"], flags)
		if m:
			text = "Match was found."
			groups = m.groups()
			if groups:
				text += " Groups: " + ", ".join(groups)
			
			c.privmsg(target, text)
		else:
			c.privmsg(target, "No match found.")
	
	def cmd_replace(self, cmd, c, target, user, opts, args):
		flags = self.get_re_flags(opts["flags"])
		
		try:
			r = re.sub(opts["pattern"], opts["replace"], opts["string"], opts["count"], flags)
		except TypeError:
			# 2.6
			r = re.sub(opts["pattern"], opts["replace"], opts["string"], opts["count"])
		c.privmsg(target, r)
	
	def cmd_stats(self, cmd, c, target, user, opts, args):
		for chname, chobj in self.channels.items():
			c.privmsg(target, "--- Channel statistics ---")
			c.privmsg(target, "Channel: " + chname)
			users = chobj.users()
			users.sort()
			c.privmsg(target, "Users: " + ", ".join(users))
			opers = chobj.opers()
			opers.sort()
			c.privmsg(target, "Opers: " + ", ".join(opers))
			voiced = chobj.voiced()
			voiced.sort()
			c.privmsg(target, "Voiced: " + ", ".join(voiced))
	
	def cmd_log(self, cmd, c, target, user, opts, args):
		if opts["date"] is None:
			opts["date"] = time.strftime(self.log_name_format)
		
		logfile = "logs/{0}.log".format(opts["date"])
		if not os.access(logfile, os.F_OK):
			raise(BotError("Log file for {0} does not exist. Date must be given in YYYY-MM-DD."))
		
		c.privmsg(target, "Log file exists, but I can't send it. :(")
		#c.dcc_send(user, logfile)
	
	def cmd_wall(self, cmd, c, target, user, opts, args):
		Popen(["wall", "IRC message from {0}:\n{1}".format(nm_to_n(user), opts["message"])])
		c.privmsg(target, "Message sent.")
	
	def cmd_ls(self, cmd, c, target, user, opts, args):
		try:
			dirlist = os.listdir(opts["directory"])
		except OSError as err:
			raise(BotError(err.strerror))
		
		dirlist.sort()
		c.privmsg(target, " ".join([file for file in dirlist if not file.startswith(".")]))
	
	def cmd_tail(self, cmd, c, target, user, opts, args):
		if opts["date"] is None:
			opts["date"] = time.strftime(self.log_name_format)
		
		logfile = "logs/{0}.log".format(opts["date"])
		if not os.access(logfile, os.F_OK):
			raise(BotError("Log file for {0} does not exist. Date must be given in YYYY-MM-DD."))
		
		# TODO: Read only the end...
		f = open(logfile, mode="r")

def main():
	settings = Settings("settings.cfg")
	
	settings.add_option("--server", conf="server", help="hostname or IP address of the IRC server to connect to")
	settings.add_option("--port", type="int", conf="port", help="port to connect to")
	settings.add_option("--nickname", conf="nickname", help="nickname to use")
	settings.add_option("--realname", conf="realname", help="realname to use")
	settings.add_option("--channel", conf="channel", help="channel name to join")
	settings.add_option("--identification", conf="identification", help="identification scheme")
	# TODO: Only ask for a password via input if --identification is set
	settings.add_option("--password", type="password", hide_default=True, help="password to identify the bot with")
	settings.add_option("--log", type="bool", default=True, conf="log", help="log primary channel events")
	settings.add_option("--debug", type="bool", default=False, conf="debug", help="show irclib debug messages")
	
	bot = BotBot(settings.options)
	bot.start()

if __name__ == "__main__":
	main()
